Compute integer range bounds for bitwise AND, OR, XOR, and NOT on IntegerRangeType - #5771
Compute integer range bounds for bitwise AND, OR, XOR, and NOT on IntegerRangeType#5771phpstan-bot wants to merge 8 commits into
IntegerRangeType#5771Conversation
d3a7efd to
cf9a410
Compare
cf9a410 to
f2dae0e
Compare
f2dae0e to
200b12d
Compare
| assertType('int<0, 255>', $a | $b); | ||
| assertType('int<0, 255>', $a & $b); | ||
|
|
||
| /** @var int<0, 255> $c */ |
There was a problem hiding this comment.
instead of inline @var use parameters and @param types instead
There was a problem hiding this comment.
Addressed staabm's review comment.
Change — tests/PHPStan/Analyser/nsrt/bug-14654.php no longer uses inline /** @var */ annotations. The single foo() function was split into seven functions whose operands come in as parameters with @param types:
foo()— the original reproducer (ord()results, no annotations needed)withConstantOperand(int $c)—@param int<0, 255>, XOR/OR/AND against literal42smallRanges(int $x, int $y)—@param int<0, 20>eachdifferentRangeSizes(int $a, int $x)— AND across differently sized rangesunboundedRanges(int $unbounded, int $a)—int<0, max>staysintnegativeRanges(int $signed, int $x)—int<-10, 10>staysintbitwiseNot(...)— all four~cases includingint<min, 10>/int<-5, max>compoundAssignment(int $d, int $a)—$d &= $a
Coverage is unchanged; every assertion from the original file is preserved.
Verification
- Confirmed the rewritten file's assertions actually run: temporarily flipped
int<0, 31>toint<0, 32>and the test failed at that line. - Confirmed it still fails without the fix: checked out the pre-fix
src/, and 14 assertions failed withintinstead of the expected ranges (the only one that still passed is$c & 42, which the old constant-integer path already handled). make tests— OK, 21270 tests, 96814 assertions, 97 skipped, exit 0.make phpstan— no errors.
Committed as 3497926ed and pushed to create-pull-request/patch-vqyeygp.
There was a problem hiding this comment.
Pull request overview
Adds bounded inference for bitwise operations on integer ranges, addressing issue #14654.
Changes:
- Computes conservative ranges for AND, OR, and XOR.
- Preserves ranges through bitwise NOT.
- Adds regression coverage for bounded, unbounded, and signed ranges.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/Reflection/InitializerExprTypeResolver.php |
Adds analytical bitwise range computation. |
src/Type/IntegerRangeType.php |
Implements range-aware bitwise NOT. |
tests/PHPStan/Analyser/nsrt/bug-14654.php |
Tests bitwise range inference. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
staabm
left a comment
There was a problem hiding this comment.
add a test for
<?php
use function PHPStan\Testing\assertType;
function doFoo(int $integer) {
assertType('int<0, 3>', $integer & '3');
}
and fix the regression
| $value |= $value >> 4; | ||
| $value |= $value >> 8; | ||
| $value |= $value >> 16; | ||
| $value |= $value >> 32; |
There was a problem hiding this comment.
why is this limited to 32 bit ?
There was a problem hiding this comment.
Addressed the review comment.
Answer to "why is this limited to 32 bit?" — it wasn't. The chain >> 1, 2, 4, 8, 16, 32 is a doubling sequence that propagates the highest set bit across the full 64-bit width; >> 32 is the last doubling step, not a cutoff. Verified: allBitsMask(1 << 40) → 2199023255551 (2^41−1) and allBitsMask(PHP_INT_MAX) → PHP_INT_MAX.
Since the unrolled form reads like a 32-bit limit, I made the width explicit instead (3b286dbfa):
/**
* Propagates the highest set bit of a non-negative value into all lower bits:
* 200 becomes 255, 10 becomes 15.
*/
private static function allBitsMask(int $value): int
{
for ($shift = 1; $shift < PHP_INT_SIZE * 8; $shift *= 2) {
$value |= $value >> $shift;
}
return $value;
}This derives the shifts from the native integer width, so it also does the right thing on 32-bit builds instead of doing a no-op >> 32.
On the $integer & '3' regression from the earlier review: it was already fixed and pushed in 4599d27b1 (the range computation now runs on the toNumber()-coerced operands, after the string/error handling, with the single non-negative operand alone bounding the AND result), including the requested assertType('int<0, 3>', $integer & '3') coverage. I had independently written the same fix; after fetching I dropped my duplicate and rebased onto that commit, keeping only the mask change above.
Verification — make cs clean, make phpstan exit 0 ([OK] No errors), make tests exit 0 (21270 tests, 96814 assertions, 97 skipped). Pushed to create-pull-request/patch-vqyeygp.
|
Addressed the review — the regression is fixed and covered by a test. Root cause. The new range computation ran on the raw operand types, so it only saw Fix (
Tests ( Verification
Pushed as |
…tegerRangeType` - Add analytical bounds computation in `InitializerExprTypeResolver` for bitwise AND, OR, and XOR when operands are non-negative bounded integers, so large ranges (>128 values) that exceed the finite enumeration limit still produce tight `IntegerRangeType` results instead of plain `int` - For AND: result is `int<0, min(leftMax, rightMax)>` when both sides are non-negative; `int<0, knownMax>` when only one side has bounds and the other is an integer type - For OR and XOR: result is `int<0, allBitsMask(max(leftMax, rightMax))>` when both sides are non-negative bounded integers - Add `IntegerRangeType::toBitwiseNotType()` so `~int<a, b>` correctly produces `int<~b, ~a>` instead of plain `int` - Extract helper methods: `getNonNegativeIntegerBounds()`, `computeBitwiseAndRange()`, `computeBitwiseOrXorRange()`, `allBitsMask()`
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The range computation ran on the raw operand types, so an operand that only becomes an integer after numeric coercion (`$integer & '3'`, `$integer & true`) was not recognized and the result fell back to plain `int` - a regression against the previous constant-integer handling, which used `toNumber()`. Move the computation after the string/error handling and feed it the `toNumber()` results, captured before `optimizeScalarType()` widens integer ranges to `int`. Because the string and error cases are already handled at that point, a single non-negative operand now suffices to bound the AND result: the result's bits are a subset of that operand's bits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unrolled shifts (1, 2, 4, 8, 16, 32) already covered all 64 bits - the last doubling step is `>> 32`, it is not a 32-bit cutoff - but written out like that it reads as if the mask stopped at 32 bits. Looping while the shift stays below PHP_INT_SIZE * 8 says the same thing explicitly and also adapts to 32-bit builds of PHP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c83eb2d to
a4d4080
Compare
|
@SanderMuller please review :) |
SanderMuller
left a comment
There was a problem hiding this comment.
Reviewed. The maths is sound, the tests are reasonable, and it is perf-neutral. One suggestion below, nothing blocking.
Soundness — brute-forced rather than reasoned about
Bitwise bounds are easy to get subtly wrong, so I replicated the three formulas and checked them against ground truth: for every contiguous range pair in a window (plus one-sided-unbounded variants, negatives included) I enumerated the actual value pairs and verified the predicted interval contains every result.
ranges=281 op-combinations checked=236,883 UNSOUND=0 declined(fallback to int)=195,435
bitwise NOT: ranges=281 UNSOUND=0
No unsound case. Two things I specifically wanted to confirm, since they are the non-obvious ones:
- The single-sided
&($leftBounds !== nullalone) is correct, and it is the subtlest part of the PR.a & bwitha ∈ [0, aMax]andbentirely unknown, including negative, does stay in[0, aMax]:a >= 0means its sign bit is clear, so the result's sign bit is clear, and masking bits off a non-negative value cannot increase it. Good thatnegativeRanges()covers it. toBitwiseNotType()'s null handling.~is monotonically decreasing so the swap is right, and propagatingnullin the opposite slot is right too —int<min, 10>→int<-11, max>andint<-5, max>→int<min, 4>, both asserted.
allBitsMask() is also fine on 32-bit, where >> 32 yields 0 and the extra step is a no-op.
Gates
Full suite green (21325), self-analysis clean, phpcs clean. Worth noting no existing assertType expectation needed updating, which is itself a signal about how narrow the new condition is.
Perf: flat. Interleaved A/B on a real Doctrine/Symfony application (3267 errors over 1144 files), 2 rounds, medians, CPU as user+sys: 129.3s vs 129.3s (+0.0%), and the JSON output is byte-identical. The extra work is two getNonNegativeIntegerBounds() calls on paths that previously fell straight to new IntegerType(), so that is the expected result.
Suggestion: non-negative but unbounded operands still fall back to int
getNonNegativeIntegerBounds() requires max !== null, so the whole analytical path declines when a side has no finite upper bound. That misses some very ordinary code, since strlen(), count(), mb_strlen() etc. all return int<0, max>:
\PHPStan\dumpType(strlen($a) | strlen($b)); // int (could be int<0, max>)
\PHPStan\dumpType(strlen($a) ^ strlen($b)); // int (could be int<0, max>)
\PHPStan\dumpType(strlen($a) & strlen($b)); // int (could be int<0, max>)
\PHPStan\dumpType(strlen($a) | 8); // int (could be int<0, max>)All four are provably non-negative: |, ^ and & of two non-negative ints leave the sign bit clear, and for & a single non-negative operand is enough — which is exactly the argument the PR already relies on for the bounded single-sided case. So int<0, max> is available whenever a side is known non-negative, even with no finite max.
The reason I raise it rather than leave it: unboundedRanges() currently asserts the imprecise result (assertType('int', $unbounded ^ $a) where $unbounded is int<0, max>), which locks it in. Either extend the helper, or the assertion is fine as documentation of current behaviour — your call, but it seemed worth naming before it becomes the expected output.
Two cosmetic notes, take or leave:
getNonNegativeIntegerBounds()returnsarray{int, int}but only element[1]is ever read; returning?intwould drop the tuple and the unused$min.- The hoisted
$leftNumberType/$rightNumberTypeare deliberately computed beforeoptimizeScalarType(), while theErrorTypeguard a few lines below re-derives$leftType->toNumber()after it. Both are correct, but two identical-looking expressions now mean different things; one comment on the guard would save the next reader the double-take.
CI
The 3 reds do not look attributable: Benchmark / Test (PHP 8.5) and Run with Turbo Extension (macos-latest, 8.5, nts, make phpstan) are red across unrelated branches right now, and other-tests / dup-classes passed on four earlier runs of this same commit.
staabm
left a comment
There was a problem hiding this comment.
Fix
\PHPStan\dumpType(strlen($a) | strlen($b)); // int (could be int<0, max>)
\PHPStan\dumpType(strlen($a) ^ strlen($b)); // int (could be int<0, max>)
\PHPStan\dumpType(strlen($a) & strlen($b)); // int (could be int<0, max>)
\PHPStan\dumpType(strlen($a) | 8); // int (could be int<0, max>)
…finite max getNonNegativeIntegerBounds() required a finite upper bound, so the analytical path declined whenever a side was int<0, max> - which is what strlen(), count() and friends return. The sign-bit argument does not need a finite max: &, | and ^ of non-negative operands stay non-negative, and for & a single non-negative operand is enough. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Addressed the review — all four cases now infer Change (
Verified with Tests ( Verification
On the two cosmetic notes: the Pushed as |
Summary
When bitwise XOR (^), OR (|), or AND (&) is applied to
IntegerRangeTypeoperands whose ranges exceed 128 values (theCALCULATE_SCALARS_LIMIT), PHPStan falls back to plainintbecause it cannot enumerate all possible value pairs. This causes false positives — for example,ord()returnsint<0, 255>, soord('a') ^ ord('b')should also beint<0, 255>, but was inferred asint, makingchr()report a type error.This PR adds analytical bounds computation for all four bitwise operations so that bounded integer ranges produce tight results even when the ranges are too large for finite enumeration.
Changes
src/Reflection/InitializerExprTypeResolver.php:getBitwiseAndType(),getBitwiseOrType(),getBitwiseXorType()to try analytical bounds computation before falling back tooptimizeScalarType()(which loses range info)computeBitwiseAndRange(): for non-negative bounded integers, AND result isint<0, min(leftMax, rightMax)>. Single-side bounds also supported when the other side is verified as an integer typecomputeBitwiseOrXorRange(): for non-negative bounded integers, OR/XOR result isint<0, allBitsMask(max(leftMax, rightMax))>— the mask with all bits set up to the highest bit positiongetNonNegativeIntegerBounds(): extracts[min, max]fromIntegerRangeTypeorConstantIntegerTypewhen bounds are non-negative and finiteallBitsMask(): computes the all-bits-set mask via bit propagation (e.g., 255 → 255, 10 → 15, 200 → 255)src/Type/IntegerRangeType.php:toBitwiseNotType()override:~int<a, b>now correctly producesint<~b, ~a>instead of plainint(inherited fromIntegerType)Root cause
The bitwise methods in
InitializerExprTypeResolverrelied solely ongetFiniteOrConstantScalarTypes()for bounded results. When a range has more than 128 values (likeint<0, 255>with 256 values),getFiniteTypes()returns empty, and the method falls through toreturn new IntegerType(). The fix adds a second path: when finite enumeration fails, compute analytical bounds from the range endpoints using bitwise arithmetic properties.Analogous cases probed:
&): Same issue for ranges > 128 values. Also extended the existing constant-integer bounds logic to supportIntegerRangeTypeon both sides.|): Same issue. Same fix approach (upper bound = all-bits mask).~): Different location (IntegerRangeType::toBitwiseNotType()), same class of bug — range bounds were lost because the parentIntegerTypeimplementation returned plainint.&=,|=,^=): Delegate to the same methods, automatically fixed.<<,>>): Already have dedicated range-aware logic inInitializerExprTypeResolver, not affected.Test
tests/PHPStan/Analyser/nsrt/bug-14654.php: Regression test covering:int<0, 255>(the reported issue) →int<0, 255>int<0, 255>→int<0, 255>int<0, 20>→int<0, 31>(next power-of-2 minus 1)int<0, 20>→int<0, 20>(tighter bound)int<0, 255> & int<0, 20>→int<0, 20>)int<0, 255> ^ 42→int<0, 255>)~int<0, 255>→int<-256, -1>,~int<0, 20>→int<-21, -1>~int<min, 10>→int<-11, max>,~int<-5, max>→int<min, 4>int$d &= $apreserves boundsFixes phpstan/phpstan#14654